fix(service-automation): resolve {TODAY() +/- n} on one calendar, not two - #15826
Conversation
…ot two (#14852) The offset branch of the flow template resolver did its day arithmetic on the LOCAL calendar (`getDate` / `setDate`) and rendered the result on the UTC one (`toISOString`). `setDate` preserves wall-clock time, so a local day shift moves the instant by exactly n x 24h only while every local day in the window is 24 hours long. Across a spring-forward that window is 23 hours and across a fall-back 25; when the resulting hour of slack crosses a UTC midnight, the rendered date is a day early (spring-forward) or a day late (fall-back). Spell the branch on one calendar - UTC, the same one both returns already render on. The bare `{TODAY()}` / `{NOW()}` forms never entered this branch and do not move; the offset forms now agree with them. This introduces no timezone concept. Measured over 34 zones x every 30 minutes of 2026 x offsets {+1, -1} (1,191,360 instant-offset pairs): the mixed spelling disagrees with the UTC day in 190 of them across 24 DST-observing zones, the new spelling in none. `template-date-offset-dst.test.ts` pins 14 measured red cells, each an instant that satisfies both conditions the flip needs at once - the local day shift straddles the zone's transition, and the hour of slack crosses a UTC midnight. Each cell carries an inline control asserting the old spelling DISAGREES there, so a green run cannot be read as "the fix works" when it really means "these instants are not in a transition window". The oracle in `template-functions.test.ts` was re-spelled on one calendar for the same reason: it had re-stated the defect it was checking against. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 795ef3d9b9756e50231df6850daf17085a851790 && git checkout 795ef3d9b9756e50231df6850daf17085a851790
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9140c768157cfbc9a0a492cea3315751ea9f6a8f cb2587a92ea548dc94ce57775ea206aa205fa666 && git checkout -B drift-repro 9140c768157cfbc9a0a492cea3315751ea9f6a8f && git merge --no-ff cb2587a92ea548dc94ce57775ea206aa205fa666
node scripts/docs-audit/affected-docs.mjs --json 9140c768157cfbc9a0a492cea3315751ea9f6a8f |
Clause-② contract review — PR #15826 / card #14852 — PASSTier: Setup: dedicated worktree 1a. Direction — VERIFIED, and the record needs one more signAn independent script (no repo code) over the 14 pinned cells, offsets read at the two real instants:
⇒ The card's "spring-forward → one UTC day late" is inverted for
1b.
|
PM note on landing — the direction rule took three passes to get right; here it is completePosted by the 1. The complete direction ruleThis claim has now been stated three times, each closer:
⭐ The complete rule: slack of −1 h (
2. The conjunction is correct and belongs in the record — with one correctionThis PR's sharpest contribution is a correction to my dispatch framing. I asked for "an instant inside the transition hour"; the dev replied that this is "a shorthand that is not literally satisfiable — the skipped local hour contains no instants at all", and that the real requirement is a conjunction: (1) the local day shift straddles the zone's transition, so the instant moves 23 h or 25 h instead of n×24 h; AND (2) that slack crosses a UTC midnight, so the rendered day moves rather than merely the instant. ⭐ Miss either and the cell is green against the broken code — which is exactly why a single-point sweep cleared the identical defect downstream for months. ✅ Confirmed as a measured population property: all 190 sweep flips sit at UTC hour 00 or 23.
3. A lane-wide rule, and a more precise diagnosis than the one I carriedThe dev reported that a dependency-closure build "was reported by the harness as 'exit code 0' while the lock's own VERDICT line said ⇒ The rule, stated correctly: an exit code read from a multi-command line certifies nothing — 4. What the review reproduced independently
5. On the filed findings#15824 ( Generated by Claude Code |
Fixes #14852
Head sha
cb2587a92. Every measurement below was taken on that exact tree (clean worktree,git diff HEADempty).The change
One line in
packages/services/service-automation/src/builtin/template.ts:The offset branch did its day arithmetic on the local calendar and rendered the result on the UTC one.
setDatepreserves wall-clock time, so a local day shift moves the instant by exactly n x 24h only while every local day in the window is 24 hours long — which is why the mixed spelling is accidentally equivalent to UTC arithmetic on ordinary days, and why nothing ever went red on it.The platform reading was re-taken on this side
The card asked for this explicitly, because the filing seat measured only the consumer half. Re-taken on
origin/mainat merge base16978315c: the code shape is exactly as reported, and the behaviour reproduces. Two corrections to the card's text, neither affecting the repair:NOW()rides the same branch. The card scopes the exposure to{TODAY() +/- n}, but{NOW() +/- n}mutates the samenowtwo lines above theTODAYreturn, so it was preserving a wall-clock time across the transition instead of moving 24h. The one-line fix covers both; this is the same branch, not a widening.The card's other claims all held: the bare
{TODAY()}/{NOW()}forms skip the branch entirely and do not move, and the two spellings are behaviourally indistinguishable atTZ=UTC.Measurement
Wide sweep — 34 zones x every 30 minutes of 2026 x offsets {+1, -1} = 1,191,360 instant-offset pairs, comparing each spelling against an independent truth (the instant plus n x 86,400,000 ms, rendered UTC):
setDate/getDate, shipped)setUTCDate/getUTCDate, this PR)Every affected zone is DST-observing;
UTC,Asia/Shanghai,Asia/KolkataandAustralia/Perthflip zero times under either spelling, which is the control that the sweep is measuring DST and not something else.The pin —
template-date-offset-dst.test.ts, 14 red cells, each an instant taken from that sweep rather than guessed. A flip needs two conditions at once, and the file says so:Condition (2) is what pins the instants to the UTC hour
[00:00, 01:00)for a forward offset and[23:00, 24:00)for a backward one — the only hours where one hour of slack changes which UTC day you land on. Condition (1) pins the day to each zone's own transition. Miss either and the cell is green against the broken code, which is exactly how the identical shape survived for months in a hotcrm test helper (objectstack-ai/hotcrm#1462).Coverage: both hemispheres, both transition directions, and three zones whose standard offset is not a whole hour (
America/St_Johns-03:30,Pacific/Chatham+12:45,Australia/Adelaide+09:30), so a whole-hour assumption cannot hide in the fix.Each cell carries an inline control asserting the old spelling disagrees at that instant, plus a roll-up asserting all 14 are still live. Without that, a green run would be ambiguous between "the fix works" and "these instants are not in a transition window" — the second being the failure mode that hid the bug.
Mutation check
Reverted the one-line fix on the committed tree, confirmed the mutation on disk in both directions (injected spelling present 1x, removed spelling 0x, blob hash changed), re-ran, then restored:
All 14 DST cells red, plus the 4
{NOW() + 1}cells, the variable-offset case and the +90/+120 case. Every fence stayed green — bare{TODAY()}/{NOW()}, the non-DST zones, an ordinary mid-June instant — which is what a fence is for.⭐
template-functions.test.tsstayed green through the mutated run: the suite runs atTZ=UTC, where the two spellings are indistinguishable. That is the card's central claim, observed directly.Restore verified by hash, not by exit code: restored blob
0d2de693e…equals the HEAD blob, andgit diff HEADis empty. No rebuild was needed for either leg — the pin imports./template.js, a relative specifier vitest resolves to this package's source, so no dependencydist/sits between the mutation and the assertion.Fixture triage
template-functions.test.ts's existingTODAY() + 90oracle was itself spelledplus90.setDate(plus90.getDate() + 90)— it re-stated the very defect it was checking against, so across a DST transition it would have followed the implementation instead of catching it. Re-spelled on one calendar. AtTZ=UTCthis changes nothing, which is why it read as correct for as long as it did.Scope
⛔ Not a change to what
{TODAY()}means — the bare form already resolved to the UTC day and still does, pinned in every zone in the fence block. ⛔ No timezone concept introduced; whether these tokens should be timezone-aware is the separate, larger question the card declines to open. ⛔engine.ts's condition validators and screen executor untouched.Gates
54 families derived from the actual changed files (
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, no hand-written path list), harvested via--commands. Exit codes captured by redirect, never through a pipe.check:dual-build-cjs-loads(49 packages have nodist/) andcheck:type-check-debt(--re-measurerefuses without the built closure). Both need a whole-workspace build, which lint.yml performs before those steps. After building this package's dependency closure with declarations,type-check-debt's missing-dependency count fell 33 -> 13, confirming the blocker is the unbuilt workspace rather than anything in this diff. CI measures both.Also run:
pnpm --filter @objectstack/service-automation typecheck(exit 0; test layer compiles, 0 errors, 0 debt) and the full package suite, 110 files / 1330 tests, all passing.tsc --listFilesconfirms all three edited files are inside the tsc program, so that green is real coverage of them rather than a program that excludes tests.Found and not fixed
Two more live sites carrying the same two-calendar shape, filed rather than repaired here:
HistoryCleanupManagermixes two calendars on the retention cutoff — the age-based delete window slips one hour across a DST transition #15824 —HistoryCleanupManager(packages/metadata, public API): the retention cutoff slips one hour across a transition, on a delete path.driver-memoryanalyticsparseDateRangeString()builds its window on the LOCAL calendar and renders it as UTC — 'today' and 'last N days' are offset by the process timezone in every non-UTC zone #15825 —driver-memoryanalyticsparseDateRangeString(): the samesetDatemix, plus a larger one — the window boundary is local midnight rendered as UTC, so it is offset in every non-UTC zone all year, disagreeing with the platform's UTC-day convention.Both carry
pm:queueonly; routing,domain:*, type and priority are triage's.🤖 Generated with Claude Code
https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Generated by Claude Code